Efficient analytic gradient of the LMM objective + gradient-based optimizers - #903
Efficient analytic gradient of the LMM objective + gradient-based optimizers#903palday wants to merge 66 commits into
Conversation
…ixedModels.jl into db/pa/gradient
Replace the per-parameter blocked-solve gradient evaluation with an
adjoint formulation: the objective is affine in log diag(L), so
grad_p = <S, dOmega_p> with S = L'^{-1} W L^{-1} shared by all
parameters. A single blocked computation of the lower blocks of L^{-1}
(GradientWorkspace + _invL!) followed by weighted Gram products against
the sparse structure of the A blocks yields the whole gradient, for ML,
REML, and fixed-sigma objectives.
- export objective_gradient!(g, m[, theta]), returning the objective value
- NLopt backend: fill the gradient slot for the (opt-in) LD_LBFGS,
LD_MMA, and LD_SLSQP optimizers; the default remains LN_NEWUOA;
profiling falls back to a derivative-free optimizer
- test/grad.jl: compare against the ForwardDiff extension across the
model zoo at initial, perturbed, and converged theta, plus REML,
fixed sigma, and gradient-based fit smoke tests
- remove the per-parameter WIP gradient code
The gradient evaluation is 73x faster than ForwardDiff on the maximal
kb07 model and 8x faster on insteval; gradient-based fits need far
fewer objective evaluations on many-parameter models (mrk17_exp1:
131 vs 2537; kb07 maximal: 71 vs 845).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Blocks of X = L⁻¹ in the GradientWorkspace now mirror the structure of the corresponding blocks of L: Diagonal and UniformBlockDiagonal diagonal blocks stay block-diagonal, and BlockedSparse off-diagonal blocks (nested grouping factors) are stored as SparseMatrixCSC mirrors sharing the pattern of the L block, gated by a construction-time check (block-diagonal fill block, block-aligned columns, pattern containment for intermediate terms). Pairs with a sparse A block between terms of any dimension evaluate only the entries of S on the pattern of A into a sparse buffer contracted blockwise, instead of allocating dense S/C1/C2 buffers. For the fggk21 model (1 + Test | Child nested in School, q ≈ 545k) the workspace previously requested a dense 541475² block (~2.3 PB); it is now ~1 GiB, about 2.3× the storage of L itself, and fit! with LD_LBFGS peaks at ~3.5 GB RSS. The kb07/ml1m benchmarks and all previous gradient paths are unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Store the per-pair evaluation route explicitly in the workspace (GradientWorkspace.path) instead of re-deriving it at evaluation time from the runtime type of the S buffer, where a 0×0 placeholder implicitly meant the scalar path. - Drop the sparse'sparse _gramacc! method that allocated a sparse product per gradient evaluation; SparseArrays' five-argument mul! for adjoint(sparse) × sparse into a dense target is allocation-free, so the generic fallback covers it. - Share the per-entry inner products between the scalar and selected-entry accumulation paths (_xdot/_xydot) so the [Xy]-row weighting convention has a single definition. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… into pa/gradient-fable
# Conflicts: # NEWS.md # ext/MixedModelsForwardDiffExt.jl # src/gradient.jl # src/linalg/cholUnblocked.jl # src/linalg/rankUpdate.jl # src/linearmixedmodel.jl # test/grad.jl
The face and block loops in `objective_gradient!` issued a `mul!` per face or per nonzero block on `k_r × k_b` temporaries, where `k_r` and `k_b` are term dimensions. Those products are at most 4×4 in any model seen in practice, far too small to amortize a BLAS call: dispatch and setup dominated the handful of flops. Profiling `d3` put 19% of the gradient in `_selcontract!` and 11% in `_gramfaces_acc!`, nearly all of it call overhead. `ReMat` carries its term dimension as a type parameter, so dispatching on the `ReMat` makes it a compile-time constant and the arithmetic unrolls into `SMatrix` register operations. `StaticArrays` was already a direct dependency; the import is widened to `SMatrix`. Converted: `_selcontract!` (split into `_selcontract_static!` and `_selcontract_generic!`), all four `_gramfaces_acc!` methods plus the `_gramfaces!` plumbing, the face loop of `_diagpair!` (extracted as `_diagpair_faces!`), and `_facecontract!` / `_facecontract_rows!`. The previous `mul!` implementations remain as fallbacks past `GRAD_STATIC_K = 4` so compile time and register pressure stay bounded for unusually wide terms. `_facecontract!` also gates on the per-face row count, which unlike the others is not a term dimension. Its faces are contiguous column slices, so a single `gemm` per face wins once there are enough rows; the measured crossover is near 48, hence `GRAD_STATIC_ROWS = 32`. Its two call sites straddle the cutoff — `_xypair!` passes p + 1 rows, `_densepair!` the full term-r row count — so without the gate the dense pair path would regress. `_facecontract_rows!` needs no gate: its generic path slices rows and `gemm` on the resulting strided views never beats the unrolled loop. Gradient evaluation, minimum of 30 evaluations against the merge base: d3, vector-valued terms 14.19 ms -> 8.93 ms 1.6x d3, scalar terms 2.92 ms -> 1.59 ms 1.8x pastes, nested 20 us -> 6 us 3.3x kb07, maximal 504 us -> 453 us 1.1x insteval (dense and RFP) unchanged `insteval` is dominated by the large triangular solves in `L⁻¹` and does not move. Accumulating each face in registers and writing back once reorders the additions, so gradient components differ from the previous implementation in the last few bits; agreement is to roughly 11 significant digits. This is unlike #910, which was bit-identical. Also adds `@inbounds` to the sparse-sparse `_mulsub!`, the only sparse kernel in `gradient.jl` without it. Its inner walk already ran unguarded on the precondition pattern(A * B) ⊆ pattern(C), which `_sparseXok` establishes once at workspace construction via `_productcontained` — the same "check once, then `@inbounds` the tight loop" argument #910 made for `issorted`. New `test/grad.jl` testset checks every kernel against a dense reference at term dimensions 1, 2, `GRAD_STATIC_K`, and `GRAD_STATIC_K + 1` (the last exercising the fallbacks), and `_facecontract!` at row counts either side of `GRAD_STATIC_ROWS`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Some current timings:
|
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #903 +/- ##
==========================================
- Coverage 94.89% 92.82% -2.08%
==========================================
Files 38 39 +1
Lines 3958 4947 +989
==========================================
+ Hits 3756 4592 +836
- Misses 202 355 +153
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
In the comments at the beginning of I would be quite happy to have a formula that only works for ML and REML estimation. |
Summary
This PR adds an efficient analytic gradient of the profiled objective (deviance / REML criterion) for
LinearMixedModel, exposes it as the exported functionobjective_gradient!, and wires it into the NLopt backend so that the gradient-based optimizers:LD_LBFGS,:LD_MMA, and:LD_SLSQPcan be selected viafit(MixedModel, form, data; optimizer=:LD_LBFGS). The default optimizer remains the derivative-free:LN_NEWUOA.It also reworks the ForwardDiff extension to reuse the core linear-algebra pipeline instead of maintaining parallel
fd_*implementations, and adds agradientkeyword tofit/fit!(:analyticdefault,:forwarddiffopt-in) so that ForwardDiff can serve as an alternative gradient source for the LD optimizers — see the sections below the BLAS-3 one.Approach
The objective is an affine function of the logarithms of the diagonal elements of the blocked Cholesky factor,
obj = 2 Σⱼ wⱼ log Lⱼⱼ + const, with weights 1 on the random-effects rows, 1 (REML) or 0 (ML) on the fixed-effects rows, and n, n − p, or pwrss/σ² on the ℓ_yy element for ML, REML, and fixed-σ objectives respectively. Differentiating Ω = LLᵀ (Murray 2016, arXiv:1602.07527) gives∂obj/∂θₚ = tr(W L⁻¹ Ω̇ₚ L⁻ᵀ) = ⟨S, Ω̇ₚ⟩, S = L⁻ᵀWL⁻¹,
where S is shared by all parameters. A single blocked computation of the lower blocks of L⁻¹ (stored in a reusable
GradientWorkspace), followed by weighted Gram products contracted against the sparse structure of the A blocks, therefore yields the entire gradient: Ω̇ₚ is never formed and no solve is repeated per parameter. Blocks between scalar terms with sparse A, and Diagonal/UniformBlockDiagonal diagonal blocks, use selected-entry kernels that evaluate only the entries of S that the contraction touches.This replaces the earlier per-parameter draft in
src/gradient.jl, which performed two full blocked triangular solves per parameter and disagreed with ForwardDiff for vector-valued terms (Λ where Λᵀ was needed inOmega_dot_diag_block!, and a missing Λᵣᵀ premultiplication of the off-diagonal blocks).API
objective_gradient!(g, m)— gradient at the current parameter values; returns the objective value.objective_gradient!(g, m, θ)— installs θ viasetθ!/updateL!first, mirroringobjective!.MixedModels.GradientWorkspace(m)(internal) — preallocated storage for allocation-free repeated evaluation inside the optimizer loop.fitlog, the progress display, and the reportedfminstay on the deviance scale;ftol_relis invariant under the scaling andftol_absacts as a per-observation tolerance.Performance
Gradient evaluation vs the ForwardDiff extension: ~60× faster on the maximal kb07 model, ~9× on insteval, with far less allocation.
Fits, gradient-based vs derivative-free:
Gradient-based optimization wins decisively on many-parameter models: mrk17 fits ~5× faster and reaches an objective 0.031 lower; kb07 fits ~7× faster. On ml1m (n = 10⁶, 2 parameters) LD_LBFGS needs only 13 evaluations but each gradient costs ~4× an objective evaluation, so wall time is on par with the derivative-free default;
LD_MMAandLD_SLSQPalso converge cleanly there (19 and 25 evaluations). Without the per-observation scaling, LBFGS failed outright on ml1m — see the second commit.Dense-crossed cross term (BLAS-3)
For two scalar random-effects terms whose Cholesky factor has dense fill-in (the classic crossed-effects case, e.g.
ml1m), the dominant cost of the gradient is the cross termS[r,b]on the sparsity pattern ofA[r,b], previously one BLAS-1 column dot product per nonzero — memory-bandwidth bound (~2 Gflop/s measured). When the crossing is dense enough, this is now evaluated with a BLAS-3 matrix productX[r,r]ᵀX[r,b]a column-panel at a time (transient product bounded toq_r × 128, never fully materialized), running at ~80 Gflop/s. The path is gated on crossing density (nnz > 0.03·q_r·q_b) and dense fill, so sparse crossings (e.g.insteval) keep the BLAS-1 path; results are identical up to floating-point reassociation. Onml1mthe gradient is ~1.24× faster.Note on the original selected-inverse follow-up: a Takahashi-style selected inversion does not help these models. With dense Cholesky fill the factor pattern is dense, so forming
X[r,b]directly is already cheaper than the selected-inverse alternative (which needs the full denseZ[r,r]), and the dominant selected-entry extraction cost is unchanged. The measurement that establishes this is in the commit message; the achievable win was the BLAS-3 restructuring above.ForwardDiff extension rework
The extension previously duplicated the core objective pipeline in out-of-place
fd_*variants (fd_setθ!,fd_updateL!,fd_pwrss,fd_logdet,fd_cholUnblocked!,fd_rankUpdate!). Every divergence from core was one of: array arguments instead of model fields, a BLAS/LAPACK call requiringBlasFloat, or a::Float64/::Tassertion blocking dual numbers. All six are now deleted in favor of:rankUpdate!(densesyrk!and UBD +BlockedSparsesyr!paths; the BLAS methods are nowT<:BlasFloatspecializations and the dense-C signatures are narrowed toHermOrSym{T,Matrix{T}}to avoid ambiguity with theDiagonal/UniformBlockDiagonal-storage methods) and forcholUnblocked!(genericcholesky!(Hermitian(A, :L)); theDiagonalbound is relaxed fromAbstractFloattoReal);setθ!,updateL!,pwrss, and a_logdethelper, with the model-based methods as thin wrappers.Float64 dispatch still reaches the BLAS/LAPACK methods (verified with
which);updateL!time and allocations on kb07 are identical tomain, and no new method ambiguities are introduced.Semantics change:
fd_deviancenow profiles σ (or holds it atoptsum.sigmawhen fixed) and includes the constant weights term, exactly matchingobjective.ForwardDiff.gradient/hessiantherefore refer to the profiled objective and agree withobjective_gradient!at any θ (≤1e-12 on sleepstudy) without syncing the model state first — previously agreement relied on the envelope theorem at synced state. The Hessian reference values intest/forwarddiff.jlchanged accordingly.Gradient source selection
fit(MixedModel, form, data; optimizer=:LD_LBFGS, gradient=:forwarddiff)selects forward-mode AD as the gradient source; the defaultgradient=:analyticusesobjective_gradient!. The option is stored inOptSummary(serialization-aware) and validated infit!; requesting:forwarddiffwithout ForwardDiff.jl loaded throws an informative error. The ForwardDiff path caches the dual-promoted copies ofA/L/retermstogether with aGradientConfigand aDiffResultsbuffer in a reusable workspace (fd_gradient_workspace), so repeated gradient evaluations do not re-promote; value and gradient come from a single dual sweep. Both sources use the same per-observation scaling and take identical optimization paths (same evaluation counts, same optima).Analytic vs ForwardDiff gradient source
From
gradients/fd_vs_analytic.jl(one subprocess per configuration so peak RSS is per-configuration; measured on arefit!after a warm-up fit, so time/bytes/allocs exclude compilation):The analytic gradient dominates on both axes: 55× faster than ForwardDiff on kb07 and 6× on ml1m, and — contrary to the intuition that the one-shot
GradientWorkspaceis the memory-heavy option — ForwardDiff also allocates more (1.9× the bytes on ml1m) and has a ~310 MiB higher peak RSS there, because the cached dual arrays carry nθ partials per entry.:forwarddiffis thus primarily a correctness oracle and fallback, not a memory-saving alternative.Possible follow-ups
_invL!to bound peak memory (currently O(q_r·q_b) for the fill block); invasive to the hot path, memory-only benefit at scales beyondml1m.🤖 Generated with Claude Code